با AsyncLocalStorage بر مدیریت متغیرهای محدود به درخواست در Node.js مسلط شوید. prop drilling را حذف کرده و برنامههایی تمیزتر و قابل مشاهدهتر برای مخاطبان جهانی بسازید.
رمزگشایی از زمینه ناهمزمان جاوا اسکریپت: نگاهی عمیق به مدیریت متغیرهای محدود به درخواست
در دنیای توسعه مدرن سمت سرور، مدیریت state یک چالش اساسی است. برای توسعهدهندگانی که با Node.js کار میکنند، این چالش به دلیل ماهیت تک-رشتهای، غیر-مسدودکننده و ناهمزمان آن تشدید میشود. در حالی که این مدل برای ساخت برنامههای با کارایی بالا و وابسته به ورودی/خروجی (I/O-bound) فوقالعاده قدرتمند است، اما یک مشکل منحصر به فرد ایجاد میکند: چگونه میتوان زمینه (context) یک درخواست خاص را در حین عبور از عملیاتهای ناهمزمان مختلف، از middleware گرفته تا کوئریهای پایگاه داده و فراخوانی APIهای شخص ثالث، حفظ کرد؟ چگونه میتوان اطمینان حاصل کرد که دادههای درخواست یک کاربر به درخواست کاربر دیگری نشت نکند؟
سالها، جامعه جاوا اسکریپت با این موضوع دست و پنجه نرم میکرد و اغلب به الگوهای دستوپاگیری مانند "prop drilling" متوسل میشد—یعنی ارسال دادههای مختص درخواست مانند شناسه کاربر یا شناسه ردیابی (trace ID) از طریق تک تک توابع در یک زنجیره فراخوانی. این رویکرد کد را شلوغ میکند، وابستگی شدیدی بین ماژولها ایجاد میکند و نگهداری را به یک کابوس تکراری تبدیل میکند.
اینجاست که زمینه ناهمزمان (Async Context) وارد میشود، مفهومی که یک راهحل قدرتمند برای این مشکل دیرینه ارائه میدهد. با معرفی API پایدار AsyncLocalStorage در Node.js، توسعهدهندگان اکنون یک مکانیزم داخلی و قدرتمند برای مدیریت متغیرهای محدود به درخواست به شیوهای زیبا و کارآمد در اختیار دارند. این راهنما شما را به سفری جامع در دنیای زمینه ناهمزمان جاوا اسکریپت میبرد و با توضیح مشکل، معرفی راهحل و ارائه مثالهای عملی و واقعی به شما کمک میکند تا برنامههایی مقیاسپذیرتر، قابل نگهداریتر و قابل مشاهدهتر برای کاربران جهانی بسازید.
چالش اصلی: State در دنیای همزمان و ناهمزمان
برای درک کامل راهحل، ابتدا باید عمق مشکل را درک کنیم. یک سرور Node.js هزاران درخواست همزمان را مدیریت میکند. وقتی درخواست A وارد میشود، Node.js ممکن است پردازش آن را شروع کند، سپس برای تکمیل یک کوئری پایگاه داده متوقف شود. در حالی که منتظر است، درخواست B را برداشته و روی آن کار میکند. پس از بازگشت نتیجه پایگاه داده برای درخواست A، Node.js اجرای آن را از سر میگیرد. این جابجایی مداوم زمینه (context switching) جادوی عملکرد آن است، اما تکنیکهای سنتی مدیریت state را به هم میریزد.
چرا متغیرهای گلوبال شکست میخورند
اولین غریزه یک توسعهدهنده تازهکار ممکن است استفاده از یک متغیر گلوبال باشد. برای مثال:
let currentUser; // یک متغیر گلوبال
// Middleware برای تنظیم کاربر
app.use((req, res, next) => {
currentUser = await getUserFromDb(req.headers.authorization);
next();
});
// یک تابع سرویس در عمق برنامه
function logActivity() {
console.log(`Activity for user: ${currentUser.id}`);
}
این یک نقص طراحی فاجعهبار در یک محیط همزمان است. اگر درخواست A متغیر currentUser را تنظیم کند و سپس منتظر یک عملیات ناهمزمان بماند، ممکن است درخواست B وارد شده و قبل از پایان درخواست A، currentUser را بازنویسی کند. وقتی درخواست A از سر گرفته شود، به اشتباه از دادههای درخواست B استفاده خواهد کرد. این امر باعث ایجاد باگهای غیرقابل پیشبینی، خرابی دادهها و آسیبپذیریهای امنیتی میشود. متغیرهای گلوبال برای درخواستها ایمن نیستند.
دردسر Prop Drilling
راهحل رایجتر و ایمنتر، "prop drilling" یا "ارسال پارامتر" بوده است. این کار شامل ارسال صریح زمینه به عنوان یک آرگومان به هر تابعی است که به آن نیاز دارد.
بیایید تصور کنیم که برای لاگگیری به یک traceId منحصر به فرد و برای احراز هویت در سراسر برنامه به یک آبجکت user نیاز داریم.
مثالی از Prop Drilling:
// ۱. نقطه ورود: Middleware
app.use((req, res, next) => {
const traceId = generateTraceId();
const user = { id: 'user-123', locale: 'en-GB' };
const requestContext = { traceId, user };
processOrder(requestContext, req.body.orderId);
});
// ۲. لایه منطق کسبوکار
function processOrder(context, orderId) {
log('Processing order', context);
const orderDetails = getOrderDetails(context, orderId);
// ... منطق بیشتر
}
// ۳. لایه دسترسی به داده
function getOrderDetails(context, orderId) {
log(`Fetching order ${orderId}`, context);
return db.query('SELECT * FROM orders WHERE id = ?', orderId);
}
// ۴. لایه ابزارها
function log(message, context) {
console.log(`[${context.traceId}] [User: ${context.user.id}] - ${message}`);
}
اگرچه این روش کار میکند و از مشکلات همزمانی در امان است، اما معایب قابل توجهی دارد:
- شلوغی کد: آبجکت
contextبه همه جا ارسال میشود، حتی از طریق توابعی که مستقیماً از آن استفاده نمیکنند اما باید آن را به توابعی که فراخوانی میکنند، منتقل کنند. - وابستگی شدید: امضای هر تابع اکنون به شکل آبجکت
contextوابسته است. اگر نیاز به اضافه کردن یک داده جدید به زمینه داشته باشید (مثلاً یک پرچم برای تست A/B)، ممکن است مجبور شوید دهها امضای تابع را در سراسر کدبیس خود تغییر دهید. - کاهش خوانایی: هدف اصلی یک تابع میتواند تحتالشعاع کد تکراری مربوط به ارسال زمینه قرار گیرد.
- بار نگهداری: بازسازی کد (Refactoring) به یک فرآیند خستهکننده و مستعد خطا تبدیل میشود.
ما به یک راه بهتر نیاز داشتیم. راهی برای داشتن یک محفظه "جادویی" که دادههای مختص درخواست را نگه دارد و از هر جایی در زنجیره فراخوانی ناهمزمان آن درخواست، بدون ارسال صریح، قابل دسترسی باشد.
ورود `AsyncLocalStorage`: راهحل مدرن
کلاس AsyncLocalStorage که از نسخه v13.10.0 Node.js به عنوان یک ویژگی پایدار معرفی شده، پاسخ رسمی به این مشکل است. این کلاس به توسعهدهندگان اجازه میدهد تا یک فضای ذخیرهسازی ایزوله ایجاد کنند که در کل زنجیره عملیاتهای ناهمزمان آغاز شده از یک نقطه ورود خاص، پایدار باقی بماند.
میتوانید آن را نوعی "thread-local storage" برای دنیای ناهمزمان و رویداد-محور جاوا اسکریپت در نظر بگیرید. وقتی یک عملیات را در یک زمینه AsyncLocalStorage شروع میکنید، هر تابعی که از آن نقطه به بعد فراخوانی شود—چه همزمان، چه مبتنی بر callback یا promise—میتواند به دادههای ذخیره شده در آن زمینه دسترسی داشته باشد.
مفاهیم اصلی API
این API به طرز شگفتانگیزی ساده و قدرتمند است و حول سه متد کلیدی میچرخد:
new AsyncLocalStorage(): یک نمونه جدید از store ایجاد میکند. معمولاً برای هر نوع زمینه یک نمونه ایجاد میکنید (مثلاً یکی برای تمام درخواستهای HTTP) و آن را در سراسر برنامه خود به اشتراک میگذارید.als.run(store, callback): این متد اصلی است. یک تابع (callback) را اجرا کرده و یک زمینه ناهمزمان جدید ایجاد میکند. آرگومان اول،store، دادهای است که میخواهید در آن زمینه در دسترس قرار دهید. هر کدی که در داخلcallbackاجرا شود، از جمله عملیاتهای ناهمزمان، به اینstoreدسترسی خواهد داشت.als.getStore(): این متد برای بازیابی داده (store) از زمینه فعلی استفاده میشود. اگر خارج از یک زمینه ایجاد شده توسطrun()فراخوانی شود، مقدارundefinedرا برمیگرداند.
پیادهسازی عملی: یک راهنمای گام به گام
بیایید مثال قبلی prop-drilling را با استفاده از AsyncLocalStorage بازسازی کنیم. ما از یک سرور استاندارد Express.js استفاده خواهیم کرد، اما این اصل برای هر فریمورک Node.js یا حتی ماژول بومی http یکسان است.
گام ۱: ایجاد یک نمونه مرکزی از `AsyncLocalStorage`
بهترین کار این است که یک نمونه واحد و اشتراکی از store خود ایجاد کرده و آن را export کنید تا در سراسر برنامه قابل استفاده باشد. بیایید فایلی به نام asyncContext.js ایجاد کنیم.
// asyncContext.js
import { AsyncLocalStorage } from 'async_hooks';
export const requestContextStore = new AsyncLocalStorage();
گام ۲: ایجاد زمینه با یک Middleware
مکان ایدهآل برای شروع زمینه، در همان ابتدای چرخه حیات یک درخواست است. یک middleware برای این کار عالی است. ما دادههای مختص درخواست خود را تولید کرده و سپس بقیه منطق مدیریت درخواست را در داخل als.run() قرار میدهیم.
// server.js
import express from 'express';
import { requestContextStore } from './asyncContext.js';
import { v4 as uuidv4 } from 'uuid'; // برای تولید یک traceId منحصر به فرد
const app = express();
// middleware جادویی
app.use((req, res, next) => {
const traceId = req.headers['x-request-id'] || uuidv4();
const user = { id: 'user-123', locale: 'en-GB' }; // در یک برنامه واقعی، این از یک middleware احراز هویت میآید
const store = { traceId, user };
// ایجاد زمینه برای این درخواست
requestContextStore.run(store, () => {
next();
});
});
// ... مسیرها و middlewareهای دیگر شما اینجا قرار میگیرند
در این middleware، برای هر درخواست ورودی، یک آبجکت store حاوی traceId و user ایجاد میکنیم. سپس requestContextStore.run(store, ...) را فراخوانی میکنیم. فراخوانی next() در داخل آن تضمین میکند که تمام middlewareها و کنترلکنندههای مسیر بعدی برای این درخواست خاص، در داخل این زمینه تازه ایجاد شده اجرا خواهند شد.
گام ۳: دسترسی به زمینه در هر کجا، بدون Prop Drilling
اکنون، ماژولهای دیگر ما میتوانند به طور چشمگیری ساده شوند. آنها دیگر به پارامتر context نیازی ندارند. آنها میتوانند به سادگی requestContextStore ما را import کرده و getStore() را فراخوانی کنند.
ابزار لاگگیری بازسازی شده:
// logger.js
import { requestContextStore } from './asyncContext.js';
export function log(message) {
const context = requestContextStore.getStore();
if (context) {
const { traceId, user } = context;
console.log(`[${traceId}] [User: ${user.id}] - ${message}`);
} else {
// جایگزین برای لاگهای خارج از زمینه درخواست
console.log(`[NO_CONTEXT] - ${message}`);
}
}
لایههای کسبوکار و داده بازسازی شده:
// orderService.js
import { log } from './logger.js';
import * as db from './database.js';
export function processOrder(orderId) {
log('Processing order'); // نیازی به زمینه نیست!
const orderDetails = getOrderDetails(orderId);
// ... منطق بیشتر
}
function getOrderDetails(orderId) {
log(`Fetching order ${orderId}`); // لاگر به طور خودکار زمینه را پیدا میکند
return db.query('SELECT * FROM orders WHERE id = ?', orderId);
}
تفاوت مثل روز و شب است. کد به طور چشمگیری تمیزتر، خواناتر و کاملاً مستقل از ساختار زمینه است. ابزار لاگگیری، منطق کسبوکار و لایههای دسترسی به داده ما اکنون خالص و متمرکز بر وظایف خاص خود هستند. اگر زمانی نیاز به افزودن یک ویژگی جدید به زمینه درخواست خود داشته باشیم، فقط باید middleware را که در آن ایجاد میشود، تغییر دهیم. هیچ امضای تابع دیگری نیاز به دستکاری ندارد.
کاربردهای پیشرفته و نگاهی جهانی
زمینه محدود به درخواست فقط برای لاگگیری نیست. این قابلیت مجموعهای از الگوهای قدرتمند و ضروری برای ساخت برنامههای پیچیده و جهانی را باز میکند.
۱. ردیابی توزیعشده و قابلیت مشاهده (Observability)
در یک معماری میکروسرویس، یک عمل کاربر میتواند زنجیرهای از درخواستها را در چندین سرویس ایجاد کند. برای اشکالزدایی، باید بتوانید کل این سفر را ردیابی کنید. AsyncLocalStorage سنگ بنای ردیابی مدرن است. به یک درخواست ورودی به API gateway شما میتوان یک traceId منحصر به فرد اختصاص داد. سپس این شناسه در زمینه ناهمزمان ذخیره شده و به طور خودکار در هر فراخوانی API خروجی (مثلاً به عنوان یک هدر HTTP) به سرویسهای پاییندستی گنجانده میشود. هر سرویس همین کار را انجام میدهد و زمینه را منتشر میکند. سپس پلتفرمهای لاگگیری متمرکز میتوانند این لاگها را دریافت کرده و کل جریان سرتاسری یک درخواست را در کل سیستم شما بازسازی کنند.
۲. بینالمللیسازی (i18n) و محلیسازی (l10n)
برای یک برنامه جهانی، ارائه تاریخ، زمان، اعداد و ارزها در قالب محلی کاربر بسیار مهم است. شما میتوانید موقعیت مکانی کاربر (مثلاً 'fr-FR'، 'ja-JP'، 'en-US') را از هدرهای درخواست یا پروفایل کاربر در زمینه ناهمزمان ذخیره کنید.
// ابزاری برای قالببندی ارز
import { requestContextStore } from './asyncContext.js';
function formatCurrency(amount, currencyCode) {
const context = requestContextStore.getStore();
const locale = context?.user?.locale || 'en-US'; // بازگشت به یک مقدار پیشفرض
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).format(amount);
}
// استفاده در عمق برنامه
const priceString = formatCurrency(199.99, 'EUR'); // به طور خودکار از موقعیت مکانی کاربر استفاده میکند
این کار یک تجربه کاربری یکپارچه را بدون نیاز به ارسال متغیر locale به همه جا تضمین میکند.
۳. مدیریت تراکنشهای پایگاه داده
هنگامی که یک درخواست واحد نیاز به انجام چندین عملیات نوشتن در پایگاه داده دارد که باید با هم موفق یا ناموفق شوند، به یک تراکنش نیاز دارید. میتوانید یک تراکنش را در ابتدای یک کنترلکننده درخواست شروع کنید، کلاینت تراکنش را در زمینه ناهمزمان ذخیره کنید، و سپس تمام فراخوانیهای بعدی پایگاه داده در آن درخواست به طور خودکار از همان کلاینت تراکنش استفاده کنند. در پایان کنترلکننده، میتوانید تراکنش را بر اساس نتیجه، commit یا rollback کنید.
۴. Feature Toggling و تست A/B
میتوانید در ابتدای یک درخواست مشخص کنید که کاربر به کدام پرچمهای ویژگی (feature flags) یا گروههای تست A/B تعلق دارد و این اطلاعات را در زمینه ذخیره کنید. سپس بخشهای مختلف برنامه شما، از لایه API تا لایه رندر، میتوانند با مراجعه به زمینه تصمیم بگیرند که کدام نسخه از یک ویژگی را اجرا کنند یا کدام رابط کاربری را نمایش دهند و یک تجربه شخصیسازی شده بدون ارسال پارامترهای پیچیده ایجاد کنند.
ملاحظات عملکرد و بهترین شیوهها
یک سوال رایج این است: سربار عملکردی آن چقدر است؟ تیم اصلی Node.js تلاش زیادی کرده است تا AsyncLocalStorage را بسیار کارآمد کند. این قابلیت بر روی API سطح C++ به نام async_hooks ساخته شده و عمیقاً با موتور جاوا اسکریپت V8 یکپارچه شده است. برای اکثریت قریب به اتفاق برنامههای وب، تأثیر عملکردی ناچیز است و با دستاوردهای عظیم در کیفیت و قابلیت نگهداری کد جبران میشود.
برای استفاده مؤثر از آن، این بهترین شیوهها را دنبال کنید:
- استفاده از یک نمونه Singleton: همانطور که در مثال ما نشان داده شد، یک نمونه واحد و export شده از
AsyncLocalStorageبرای زمینه درخواست خود ایجاد کنید تا از ثبات اطمینان حاصل شود. - ایجاد زمینه در نقطه ورود: همیشه از یک middleware سطح بالا یا ابتدای یک کنترلکننده درخواست برای فراخوانی
als.run()استفاده کنید. این کار یک مرز مشخص و قابل پیشبینی برای زمینه شما ایجاد میکند. - store را به عنوان Immutable در نظر بگیرید: اگرچه خود آبجکت store قابل تغییر است، اما بهتر است آن را غیرقابل تغییر (immutable) در نظر بگیرید. اگر نیاز به افزودن داده در اواسط درخواست دارید، اغلب تمیزتر است که یک زمینه تودرتو با یک فراخوانی دیگر
run()ایجاد کنید، هرچند این یک الگوی پیشرفتهتر است. - مدیریت موارد بدون زمینه: همانطور که در لاگر ما نشان داده شد، ابزارهای شما باید همیشه بررسی کنند که آیا
getStore()مقدارundefinedبرمیگرداند یا خیر. این به آنها اجازه میدهد تا در هنگام اجرا خارج از زمینه درخواست، مانند اسکریپتهای پسزمینه یا هنگام راهاندازی برنامه، به درستی کار کنند. - مدیریت خطا به سادگی کار میکند: زمینه ناهمزمان به درستی از طریق زنجیرههای
Promise، بلوکهای.then()/.catch()/.finally()وasync/awaitباtry/catchمنتشر میشود. نیازی به انجام کار خاصی ندارید؛ اگر خطایی پرتاب شود، زمینه در منطق مدیریت خطای شما در دسترس باقی میماند.
نتیجهگیری: عصری جدید برای برنامههای Node.js
AsyncLocalStorage چیزی بیش از یک ابزار راحت است؛ این یک تغییر پارادایم برای مدیریت state در جاوا اسکریپت سمت سرور است. این قابلیت یک راهحل تمیز، قدرتمند و کارآمد برای مشکل دیرینه مدیریت زمینه محدود به درخواست در یک محیط بسیار همزمان ارائه میدهد.
با پذیرش این API، میتوانید:
- Prop Drilling را حذف کنید: توابع تمیزتر و متمرکزتری بنویسید.
- ماژولهای خود را از هم جدا کنید: وابستگیها را کاهش دهید و کد خود را برای بازسازی و تست آسانتر کنید.
- قابلیت مشاهده را افزایش دهید: ردیابی توزیعشده قدرتمند و لاگگیری متنی را به راحتی پیادهسازی کنید.
- ویژگیهای پیچیده بسازید: الگوهای پیچیدهای مانند مدیریت تراکنش و بینالمللیسازی را ساده کنید.
برای توسعهدهندگانی که برنامههای مدرن، مقیاسپذیر و آگاه از مسائل جهانی را روی Node.js میسازند، تسلط بر زمینه ناهمزمان دیگر اختیاری نیست—این یک مهارت ضروری است. با عبور از الگوهای قدیمی و پذیرش AsyncLocalStorage، میتوانید کدی بنویسید که نه تنها کارآمدتر، بلکه به طور عمیقی زیباتر و قابل نگهداریتر است.